fix(plugin-security)!: evaluate the insert-side RLS check on the row that will be stored, after beforeInsert - #16805
Conversation
…ill be stored The security middleware runs before the engine's operation, so for an insert its post-image was the caller's payload as it arrived — ahead of every `beforeInsert` hook. A denormalised scoping field is what an RLS predicate compares (ADR-0055) and what an app stamps server-side so a caller cannot choose it, so the gate judged a value that never lands and ignored the one that does. Measured both ways on 17.3.0: a payload leaving the field to the hook was refused while the identical payload carrying it was admitted, and an insert naming an in-scope organization on a parent in another organization was admitted with the parent's organization stored on it. `OperationContext` gains `postHookWriteImageCheck`, a judgement an enforcement layer installs and `insert()` runs once the hook chain has produced the row — after the post-hook declared-field door, before every producer with a side effect. plugin-security installs its compiled check filter there; the update path, which already merges its pre-image, is unchanged. A seam that was installed and never run refuses the write rather than vouching for it. One conformance cell, both verbs, both drivers: the scoping field's landing decides. Refs #16608, ruling 2026-09-07. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
The insert-side RLS check is installed on the operation context and run by
`ObjectQL.insert`; a double whose executor is a bare `async () => {}` models
an engine that carries a write past a gate that never ran, which the
middleware refuses fail-closed. The doubles in `security-plugin.test.ts` and
`rls-check-membership-staging.test.ts` now run the judgement the way the
engine does — flag first, then evaluate — so they model the engine instead of
a looser approximation of it.
Also fixes the fail-closed log call to the `error(message, error?, meta?)`
contract arg order (#5637).
Refs #16608.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…sert-check-post-image
- the changeset carries its ADR-0087 disposition: an enforcement-ORDER change moves no authorable key, spelling or stored shape, so no conversion entry and nothing for an upgrader to hand-edit (check:adr-0087-registration); - the fail-closed leg's engine double routes delete/update/findOne through the real dispatch predicates, so it cannot be looser than ObjectQL (check:engine-double-contract); - `@objectstack/driver-sqlite-wasm` — the conformance cell's second driver family — is read from the producer's SOURCE on both axes: a vitest alias (check:test-source-alias) and a bare-key tsconfig `paths` rule (check:type-source-resolution). Measured: the paths route adds zero diagnostics from other packages here; the test layer still compiles at 0. Refs #16608. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…untime string `check:engine-double-contract --write` records the three newly-pinned seams in the #16608 conformance file's engine double — new pinned coverage the ledger had not learned about yet. The fail-closed developer message no longer carries the tracker id: a runtime string reaches authors and operators, none of whom can resolve `#NNNN` (check:doc-authoring). The id stays in the adjacent comment, where the reader who can resolve it is already looking. Refs #16608. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 2 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4d20f066e0e10c0c9c83943dbf5759e4a4730838 && git checkout 4d20f066e0e10c0c9c83943dbf5759e4a4730838
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 513c49556028d446db456877e0de04a92db154d9 7303fe3de5156429a9e66718c63489501064b600 && git checkout -B drift-repro 513c49556028d446db456877e0de04a92db154d9 && git merge --no-ff 7303fe3de5156429a9e66718c63489501064b600
node scripts/docs-audit/affected-docs.mjs --json 513c49556028d446db456877e0de04a92db154d9
|
Contract review (
|
… pass The contract review of this PR found one residual path where the row the seam judges is not the row that is stored: `stripRuntimeOwnedFields` and the static-`readonly` strip (with its `applyFieldDefaults` re-default) ran AFTER the seam, so a `check` over a `readonly` scoping field judged the caller's in-scope value and the store received the field's `defaultValue` — or NULL. With a default naming another organization that is this card's own headline defect one layer down: a stored row in a scope the caller does not hold. Both strips are side-effect-free, so they move ahead of the seam. The reporting half (`insertDropped` -> `strictReadonlyWrites` / `onFieldsDropped`) deliberately stays where it was, so the gate's 403 still precedes `ReadonlyFieldRejectedError` exactly as before. The seam's own comment now names what still runs between it and the driver as a closed list — the tenant fill, the secret reference, the autonumber, the multi-value normalisation — instead of claiming nothing does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…sert-check-post-image
Contract review (
|
| # | State | Evidence on e6dad93b5 |
|---|---|---|
| F1 (blocking) | closed — preferred route | engine.ts insert(): triggerHooks('beforeInsert') 10257 → seal 10277-10281 → post-hook declared-field door 10338-10349 → stripRuntimeOwnedFields 10430 → stripReadonlyFields 10484 + applyFieldDefaults re-default 10515 → seam 10586-10595 → resolveSystemInsertOrganization 10619 → refuseEmptyPasswordFields/encryptSecretFields 10655-10656 → insertDropped report 10685-10703 → normalizeMultiValueFields/validateRecord 10718-10719 → applyAutonumbers 10746 → driver. The strips reassign rowHookContexts[i].input.data = stripped and the seam builds live from rowHookContexts[i].input.data, so the judged object IS the stripped object. Cells: the readonly-scoping-field pair (no default / foreign default) × both drivers, each asserting the disjunction over the driver's table before pinning the verdict. |
| F2 | closed (filed) | #16876, open, filed 2026-09-08T12:56Z: Layer 0 wall judges opCtx.data pre-hook + injectTenantOnInsert preserves an explicit value; names this seam as the fix's host. Matches the finding. |
| F3 | closed (filed) | #16877, open, filed 2026-09-08T12:56Z: !Array.isArray(opCtx.data) guard, createManyData/insertManyData from the import runner; batch-refusal semantics named as the decision. Matches. |
| F4 | closed | (i) [#16608 F4] … an unevaluable check refuses and (ii) … consumes no autonumber and mints no secret, both × 2 drivers — see §3. |
| F5 | open, advisory (unchanged, unclearable) | The same four commits (674914d0d, 9f8402f64, 787e278b2, cd09d3b99) carry Refs #16608; the eight patch-round commits carry no card trailer. See the landing note. |
| F6 | closed (noted) | Body §F6 names the esbuild@0.28.1 collapse; re-measured 11 → 4 references vs merge-base, plus the one driver-sqlite-wasm: link: add. |
| F7 | closed (retired) | Body's 验收备注 now says the exit-4 note is RETIRED and reports the live --pair 16805 re-run as NOT MEASURED (HTTP 403 transport), not as a pass. Honest. One new stale line in the other direction — N5. |
Numbered verification
-
F1 mechanics. Side-effect-free strips: read on head (
packages/objectql/src/validation/rule-validator.ts:1206,:1392,:1519). Both helpers are copy-on-write overdata, readsupplied/hookWrittenKeys/ the schema,deletea key, and calllogger?.warn?.(…); neither throws —strictReadonlyWritesonly changes the warning text, the throw is theReadonlyFieldRejectedErroratengine.ts:10692, which is unmoved and still after the seam.applyFieldDefaultsis the same function already run pre-hook at 10172/10177, so no new code runs ahead of the seam. Claim holds. Reporting after the seam: confirmed (10685 > 10586); understrictReadonlyWritesan out-of-scope insert still answers the gate's 403 beforeReadonlyFieldRejectedError, as before. Which refusal a caller sees changes in the case the PR names and in two strict-mode variants of it — N1 — and on one narrow tenant path — N2; no other case found (the post-hook door, validation, autonumber and the statement keep their relative order on both legs). Invariant text: the changeset now reads "on every field the CALLER can steer" and names the four engine-owned passes (tenant fill of an absent column,sys_secretreference, autonumber, multi-value normalisation);engine.ts10548-10580 carries the same closed list; the sentence "Nothing between here and the driver adds a value the caller could have steered" is absent from the head file (grepped). -
The three reorder consequences. (a)
resolveSystemInsertOrganization(engine.ts:4187) returns early whenrows.every(carriesOrganization(row[tenantField])); on the new order a forged readonly tenant value is gone before that test, so derivation runs — narrowing, as stated, and confined to a non-system caller whose context carries no organization (system callers skip the strips entirely). Its edge is wider than the body's sentence — N2. (b) Verified oncd09d3b99:packages/objectql/src/engine.ts: the credential loop at 10455-10456 ran beforestripReadonlyFieldsat 10570;encryptSecretFields(head 6794, same on old) setsrow[field] = makeSecretRef(handle.id); the strip'sif (!Object.is(result[name], supplied[name])) continue;then compared a ref to the plaintext and kept the key. Mechanism confirmed; the reorder closes it; the cell pins storednull, 0encryptcalls, emptysys_secret. (c) Readonlypassword'': old orderrefuseEmptyPasswordFields(10455) threwEmptyCredentialWriteError; new order the strip removespw(supplied, not hook-written,Object.is('', '')), re-default yields nothing for a field with nodefaultValue, andrefuseEmptyPasswordFields(head 6821) sees no key → row admitted,NULLstored. The 2026-08-13 guarantee —''never at rest on a masked column — holds on both orders (read off the code, and pinned). This IS a Clause-② accept-set change at the engine level (FROMVALIDATION_ERRORTO admitted/NULL, on a readonly password field for a non-system caller), and the changeset states it with a⚠️ as "the one direction that is not a narrowing". Maintainer's call whether stripping (readonly semantics) or refusing (credential semantics) should win for a payload the caller cannot legitimately send; the PR's reasoning for not movingrefuseEmptyPasswordFieldsabove the seam (a field-level verdict must not pre-empt the RLS 403) is sound. -
F4 cells can fail. (i)
qa_unevaluable_member's policycheck: 'record.employer_org in current_user.no_such_membership_key'against a resolver publishing onlyemployer_org_ids; the cell asserts the ADR-0112 envelope, an empty table, andstampReadscontainingemp_a— the hook ran, so the write reached the engine and the refusal isevaluate's. A middleware that refused beforenext(), or a compiler that produced a passing filter, reddens it. (ii)qa_cost_membercarriescode: autonumberandtoken: secretwith a reversible fakeICryptoProvidercounting calls; a control boot's first admitted row supplies the baseline number; the subject boot's refused insert (employer: 'emp_b', in-scope payload) asserts nothing stored,encrypt === 0,sys_secretempty; the follow-up admitted insert assertscode === baseline,encrypt === 1, onesys_secretrow (the positive control that the field is on the credential path). A seam placed after either producer reddens it. Both × both drivers. No.skip/.only/.todoin the file; 25 cells counted (13 + 12), matching the body. -
Patch-round file set. The only test file that changed is the conformance file;
security-plugin.test.tsandrls-check-membership-staging.test.tsare byte-identical tocd09d3b99.scripts/engine-double-contract.pinned.json: the PR's own delta vs merge-base is the same 3 entries (insert-check-post-image.test.ts× delete/findOne/update) it had atcd09d3b99; the 12 further entries incd09d3b99..headare all present onorigin/main(seed-loader, list-user-invitations, mount-storage-routes) — they arrived by merge, not by--write.engine-double-contract.baseline.json(shrink-only) is untouched. -
Governed paths touched: NO —
git diff afa3a2698..refs/review/16805 --name-only(11 files) hits none ofdocs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md,content/docs/releases/**. Changeset:@objectstack/plugin-security: minor,@objectstack/objectql: minor;**BREAKING**banner present;<!-- adr-0087: not-required (no-migration-prescription) … -->present. Both gates run from the head's own script copies (identical toorigin/main's) with--base origin/main --head refs/review/16805:check-changeset-no-major→ exit 0 (no major bump; LEVEL axis NOT MEASURED without a PR payload, as before);check-adr-0087-registration→ exit 0 ([BREAKING+bang] not-required (no-migration-prescription), reason parsed). -
CI on
e6dad93b5: 41 check runs — 33 success, 6 skipped, 2 failure, bothPart-of PR must not also close its card.Lint & Repo Gateswasin_progressat first read and completed success at 14:58:52Z; Test Core 1-6, Type Check ×4, Temporal Conformance, Governed Surface Queue Guard, Check Changeset, Build Core/Docs all green.mergeable_statereadblockedwhile that required check was still running and readsunstablenow (draft + the advisory red) — so theblockedwas the pending required context, not the partof gate, which its own log says is "absent from the required-context registry". -
Distance: 4 commits behind
origin/main(21aabbc7b); none touches a PR file (spec/lint/docs/CHANGELOGs);git merge-tree --write-tree origin/main refs/review/16805→ clean, no conflicts. A re-merge is not needed for correctness; the queue's call.
Clause-②: yes
Accept-set cases and whether the changeset states FROM/TO: (i) hook-stamped field left off the payload — FROM 403 TO admitted: stated. (ii) in-scope value, parent in another org — FROM admitted (parent's org stored) TO refused, nothing stored: stated. (iii) duplicated in-scope stamp — unchanged: stated. (iv) host/double that never runs the seam — FROM admitted TO refused + ERROR log: stated. (v) readonly scoping field, caller sends an in-scope value, hook silent — FROM admitted with defaultValue/NULL stored (17.3.0 judged the caller's value) TO refused: entailed by the invariant sentence and the "both moved ahead of it" clause, not enumerated — N3. (vi) readonly secret forgery — FROM encrypted+stored TO stripped: stated. (vii) readonly password '' — FROM VALIDATION_ERROR TO admitted/NULL: stated, flagged as the non-narrowing direction. Scope unchanged (explicit check, single row, non-system caller).
New findings (none blocking)
- N1 — (minor, changeset) the strict-mode variants of (vi)/(vii) are not named. Under
strictReadonlyWrites, the readonly-secretforgery goes FROM admitted (stored ref) TOReadonlyFieldRejectedError, and the readonly-password''goes FROMVALIDATION_ERRORTOReadonlyFieldRejectedError— becauseinsertDroppednow contains the stripped key on both. Both are refusals of an un-sendable payload; the changeset states only the non-strict readings. Expectation: one sentence, or accept as implied; not a merge condition. - N2 — (minor, doc)
resolveSystemInsertOrganization's consequence has a sharper edge than "no longer suppresses derivation". On the reviewed order a non-system caller with no context organization who forged a readonly tenant column suppressed derivation AND then lost the value to the strip — a tenant-less row. On the new order derivation runs and, in a multi-organization posture, can throwSystemWriteOrganizationRequiredError. A narrowing that closes a worse outcome; unexercised, as the body says. Expectation: none required; a pin would close it. - N3 — (minor, changeset) case (v) above as an enumerated FROM/TO row. Expectation: one bullet under "Who is affected"; the invariant sentence already entails it.
- N4 — (note) a refused insert now emits the strips' WARN lines (
readonlyStripWarning/runtimeOwnedStripWarning) before the gate's 403; previously a refused write never reached the strips. Log-only;onFieldsDroppedcorrectly does not fire on a refusal. Expectation: none. - N5 — (stale body line)
needs:contract-reviewIS on the PR now (labels read over MCP), while the body says twice that neither carrier holds it and re-hanging is the PM's step. Expectation: fold into the next body edit so the squash message (which must equal the body — landing note) is not stale on landing.
For the maintainer at landing
check:partof-closing-keyword RULE 2 is red on both runs (advisory): four commits carry Refs #16608, none carries a closing keyword, the body carries Fixes #16608. Per the gate's own measurement the repository squashes from commit messages, so the squash message must be replaced with the PR body by hand at the merge button, or the Refs trailers land in permanent history (they move no card). A queue merge does not do that replacement.
Maintainer-only merge: yes — fix(…)!, Clause-②: yes, p1 security seam, draft, needs:contract-review on the PR; and the patch round moved two engine passes across the seam, which is a change to the seam's own contract. This seat did not approve, request changes, label, edit or push anything.
Generated by Claude Code
Queue-entry record — one non-required check red BY DESIGN on this head (director seat, summon #20, 2026-09-09T05:0xZ)Landing authorized by the maintainer (batch #105 item 2, 「其他同意」, ruling on #16608 at 5595724657) on the contract-review PASS at
Every other check on Generated by Claude Code |
…sert-check-post-image # Conflicts: # pnpm-lock.yaml
Fixes #16608
The insert-side RLS
checkis evaluated on the row that will be stored — afterbeforeInsert— instead of on the caller's raw payload, soinsertandupdatejudge the same thing.Clause-②: yes— the security middleware's accept / refuse behaviour changes. Stays in draft: maintainer-only merge.Contract review: DONE, and its blocking finding is fixed in this branch. An isolated
CONTRACT_REVIEW_TIERseat reviewed headcd09d3b99and returned CHANGES REQUIRED on one finding (comment 5581120293). That finding is F1 below; the patch round that answers it is the second half of this body.needs:contract-reviewwas removed from both carriers when the review concluded — re-hanging it against the patched head is the PM seat's step, not this branch's.The ruling this implements
Maintainer, 2026-09-07, director seat, summon #17, decision batch #3, verbatim: 「同意」 (issue comment 5572345610).
⛔ Option 2 (keep the order and write the contract that a checked field must arrive from the caller, plus an
os validaterule) is refused, not deferred: it institutionalises the contradiction — the caller sending the value the hook exists to make un-sendable — and adds a permanent rule to keep it. Nothing here drifts back to it.The two measurements the ruling required first
Both were taken on
origin/main@941232040before the evaluation point moved, and posted in full on the card (comment 5579761278). Summary:① The bypass case — IT REPRODUCES. The ruling flagged this as reasoning, not measured. It is now measured, on a real engine with a real
SecurityPluginand a real driver. A caller holdingorg_asendsemployer_org: org_aon a child whose parent belongs toorg_b; the app'srunAs: 'system'stamp reads the parent outside RLS and overwrites the field.{ employer: 'emp_b', employer_org: 'org_a' }{ employer: 'emp_b', employer_org: 'org_b' }{ employer: 'emp_a' }— left to the hook (the card's row 2)PERMISSION_DENIED{ employer: 'emp_a', employer_org: 'org_a' }(the card's row 1)employer_org: 'org_a'Today's order admits a row whose stored scope the caller does not hold. That is a cross-organization write, and it is what regrades the card to
priority:p1. After this PR: row 1 is refused with nothing stored, row 2 is admitted and storesorg_a, row 3 is unchanged.② The census — non-zero, and large: 29 hooks. Reported as a fork, with the list, on the card. Every
beforeInserthook whose derivation reads a caller-supplied field: 4 in this repo, 7 in ats @c3c6526, 18 in hotcrm @d47e37a. The per-hook table is on the card.⛔ Not a silent fallback to option 2. What this PR installs instead of 29 per-hook declarations is one invariant at the gate, which is exactly the guard the census was asking for. Its precise statement is in the changeset, and it is narrower than the first draft claimed — see F1.
What changed
@objectstack/objectql—OperationContext.postHookWriteImageCheck. An optional judgement an enforcement layer installs andObjectQL.insertruns once thebeforeInsertchain has produced the row. It sits after the post-hook declared-field door (#13657), after the two value-changing strips, and before every producer with a side effect —resolveSystemInsertOrganization,encryptSecretFields(which writes asys_secretrow),applyAutonumbers(which CONSUMES a sequence number), validation, the statement — so a refusal still costs nothing, the same rule #8682 wrote for the door. Rows the declared-field door culled from a partial batch are skipped: they will not be written.honouredis set beforeevaluate, so a throwing check still reads as honoured — the flag answers "did the seam run", never "did the write pass".@objectstack/plugin-security— step 3.6 installs instead of matching. Forinsertthe compiledcheckfilter goes on the operation context; forupdatenothing changes (that path already merges its caller pre-image, and the ADR-0090 D10 delegator half rides along unchanged). The filter is still compiled in the middleware, where the caller's permission sets, the delegator's, the staged membership and this request's context are all resolved. Only the IMAGE is deferred; deferring the compilation would move authorization inputs into the engine's timeline for no gain. Both verbs share one refusal closure, so a caller cannot tell which side judged.Fail-closed on a seam that never runs. A middleware that installed the judgement and finds
honouredunset refuses the write and logs at ERROR, with a developer message that says the check was not evaluated rather than that it failed — different facts, and an operator debugging one must not be handed the other. ⛔ Deliberately not softened to a warning: a middleware that cannot say a write was checked must not report that it was.Scope kept, not widened. Only objects governed by a permission set that EXPLICITLY declares
check, single-row inserts, non-system caller — the gate's existing scope. Batch inserts were never post-image validated here and still are not (now filed as #16877).The contract-review patch round
Review: PR comment 5581120293 (isolated
CONTRACT_REVIEW_TIERseat, headcd09d3b99). Handoff: card comment 5582300461.F1 (BLOCKING) — the row the seam judged was not the row that was stored
What the review measured. The seam was placed correctly with respect to side effects but not with respect to value changes. Two engine passes still ran after it, and both can change a value a caller sent:
stripRuntimeOwnedFields(engine.ts, the [17.0-rc2验收] autonumber 字段可被普通调用者改写:POST 提交显式值绕过序列、PATCH 直接改号落库 —— readonly 剥离不保护 type:'autonumber' #5503 pass), andreadonlystripstripReadonlyFieldswith itsapplyFieldDefaultsre-default (the finding: the insert-pathreadonlystrip is a protocol-boundary guard only —engine.insertapplies none of it, andcreate_record'sonFieldsDroppedchannel can never fire for a readonly drop #14147 pass, ruling C of 2026-09-03).A
checkover areadonlyscoping field — which is the natural shape, because ADR-0055 forces the predicate onto the denormalised column andreadonly: trueis how an author says "not yours to send" — therefore judged the caller's in-scope value while the store received the field'sdefaultValue, orNULLif it had none.Route taken: the review's PREFERRED fix, not the alternative. Both strips move ahead of the seam. They are side-effect-free — they read
suppliedPerRow,rowHookWrittenKeys, the schema andoptions, all resolved above the seam, and they log — so the move costs nothing and buys the seam the final row.insertDroppedis still discharged after the seam (strictReadonlyWritesrefusal,onFieldsDropped), because moving it too would putReadonlyFieldRejectedErrorahead of the gate's 403 and change which refusal a caller sees. The strips are value-changing; their report is not.What the reorder actually moves — measured on BOTH legs, not reasoned. Three engine passes now see the stripped row where they used to see the unstripped one. The two that are observable were measured by checking
cd09d3b99'sengine.tsout over this tree and running the same cells against it (on-disk swap proven by blob hash, restored under atrap,git diff HEADempty afterwards):resolveSystemInsertOrganization— a caller-forged value on areadonlytenant column no longer suppresses the platform's organization derivation. A narrowing; read off the code path, not separately exercised.⭐
encryptSecretFields— this one found a pre-existing hole, and the reorder closes it. A caller-forged value on an author-declaredreadonlysecretfield was encrypted and stored on the reviewed order:cd09d3b99(reviewed order)token"secret:sec_1"nullICryptoProvider.encryptcallssys_secretrows mintedThe mechanism: the credential channel ran first and replaced the row's value with a reference (
row[field] = makeSecretRef(handle.id)), so the strip'sObject.isvalue test then compared that reference against the caller's plaintext, read the difference as "a hook rewrote this key", and kept the forgery. That is the one input on which that test inverts. It predates this card — it is 17.3.0's behaviour — and it is now a cell.refuseEmptyPasswordFields— the one direction that is NOT a narrowing. Areadonlypasswordfield carrying''answeredVALIDATION_ERROR("Empty string refused for password field") on the reviewed order and is stripped here, with the row admitted andpwstored asNULL.''reaches the store on neither order, so the 2026-08-13 empty-credential ruling's guarantee — a masked column must never read as "set" while holding nothing — is untouched; what moves is which refusal a caller sees, on a payload the caller was never allowed to send. ⛔ Deliberately not "fixed" by movingrefuseEmptyPasswordFieldsup as well: that would let a field-level validation verdict answer a write the RLS gate refuses, which is the wrong precedence for a security gate. Flagged for the maintainer rather than buried; it is a cell, and the changeset carries it.The invariant is now stated to its real edge, and the claim is not deleted. The changeset said "a stored row always satisfies the insert
check, whatever the caller sent". The review's verdict on that sentence was that it is false. It now reads: a stored row satisfies the insertcheckon every field the caller can steer — and it names the four engine-owned passes that still substitute a platform value afterwards (the tenant fill of an absent column, thesys_secretreference, the autonumber, multi-value normalisation), so a policy whosechecknames one of those fields is documented as a boundary rather than covered by a promise.engine.ts's seam comment carries the same closed list; the sentence "Nothing between here and the driver adds a value the caller could have steered" is gone, because it was the sentence that was false.F4 — the two claims that had no pin
Both are new cells, on the same both-drivers footing as the rest of the file.
checkrefuses. A policy whosechecknames acurrent_user.*key no resolver publishes compiles toRLS_DENY_FILTER, the fail-closed sentinel that matches no row. The cell asserts the refusal on the ADR-0112 envelope with nothing stored — and asserts it through the seam: thebeforeInsertstamp is observed to have run, which it could only have done if the middleware handed the write to the engine, so the refusal isevaluate's and not the middleware's.autonumberand asecretfield, with a real reversibleICryptoProviderwired soencryptSecretFieldsruns its real path. After a refused insert: nothing stored,encryptcall count 0,sys_secrettable empty. Then the same insert admitted: the survivor's record number equals the number a control boot that never refuses anything hands its first row — so the refused attempt drew no sequence value — and exactly oneencryptcall with exactly onesys_secretrow, which is also the positive control that the field really is on the credential path.F2 / F3 — filed, not fixed here
beforeInserthook that writes the tenant column decides the stored organization unjudged #16876 — the Layer 0 tenant write wall still judges the PRE-hook image, andinjectTenantOnInsertpreserves an explicit tenant value, so abeforeInserthook that writes the tenant column decides the stored organization unjudged. Pre-existing, wider than this gate (it applies with or without a declaredcheck), and the seam this PR adds is the obvious host for the fix. Not fixed here: moving a second, wider gate's evaluation point is a second accept/refuse change and wants its own decision.check-gated — the write gate's own guard skips any array payload, and the REST import runner reachesengine.insertwith one #16877 — array inserts are nevercheck-gated (!Array.isArray(opCtx.data)in the gate's own guard), and the REST import runner reachesengine.insertwith an array throughcreateManyData/insertManyData. Promoted from "noted, not filed" at the review's instruction: a caller-reachable route around a p1 gate should have a number. Cheaper now than before — the seam already receives every live row — but the batch refusal semantics are a decision, not a mechanical lift.F6 — the lockfile carries more than the one devDependency add
Noted rather than dropped, which is the review's first option. Beyond the
@objectstack/driver-sqlite-wasmdevDependency this PR adds,pnpm-lock.yamlcollapses 7esbuild@0.28.1peer-resolution entries (11 references at the merge base, 4 now). It is unrelated normalisation that a plainpnpm installproduced; Install / Build / Validate Package Dependencies are green on it. ⛔ Not hand-edited back: a lockfile edited to look tidier than the resolver's own answer is a worse artifact than an untidy honest one.The conformance case
packages/plugins/plugin-security/src/insert-check-post-image.test.ts— 25 cells, all green (13 from the first delivery, 12 added by this patch round).The original proposition, the scoping field's landing decides, is written once and asserted for both verbs on both driver families (
driver-sqlbetter-sqlite3:memory:, anddriver-sqlite-wasm):PERMISSION_DENIED/ 403 / the catalog sentence / the developer line), and nothing moved — refusal and non-landing are asserted as separate facts, because a gate that refuses after the row lands is not a gate.The insert arm's out-of-scope cell differs from its in-scope twin by the parent alone: both payloads carry an in-scope
employer_org, so they are indistinguishable to the pre-hook image and no green there can come from it. Plus the card's two rows (bare payload admitted; duplicated stamp still admitted), and the fail-closed leg against an engine double that ignores the seam.The twelve new cells, each on both drivers:
defaultValue, and adefaultValuenaming an organization the caller does not hold. Each asserts the review's disjunction over the driver's own table (either the insert is refused or the stored row satisfies the check) before pinning the answer the runtime gives, so the cell states the invariant rather than a verdict.checkcell.readonlysecretforgery and thereadonlypasswordempty string, both with their reviewed-order readings quoted in the cell so the change of behaviour is legible from the test rather than only from this body.Ablation
Two ablations, one per delivery. Both mutate on disk, prove the mutation reached the disk before reading any result, and restore under a
trap ... EXIT INT TERM. Both packages resolve to SOURCE in this package's vitest config (@objectstack/objectqlis aliased there), so no build stands between the edit and the run.Round 1 — the seam itself. Mutating the installed judgement to read
opCtx.datainstead of the rows the engine hands it:Round 2a — the F1 fix, minimally reverted. The seam judges a snapshot of the rows taken before the strips, which is the reviewed head's behaviour with everything else unchanged.
⭐ Read the failure text: with the fix reverted, the row lands, carrying
org_b, for a caller holding onlyorg_a. F1 is this card's own headline defect one layer down, and that is the cell that measures it.Round 2b — the whole conformance file against the reviewed head's engine.
cd09d3b99'sengine.tschecked out over this tree, which is the strongest available "before":Every cell this patch round adds is therefore MEASURED: each one fails on the head the review read, and passes here.
Tests and gates
Measured on the pushed head
e6dad93b5, after mergingorigin/main(a merge commit, never a rebase — this branch is published).pnpm --filter @objectstack/plugin-security testpnpm --filter @objectstack/objectql testtypecheck, both packagesdispatch-gates.mjs --ranreconciliationdispatch-gatesreported STALE TREE repeatedly (first 52 commits behind with 19 gate-source files changed, then 5 more), soorigin/mainwas merged in — a merge commit, never a rebase — and the union re-derived from the fresh tree each time before it was run.⭐ Zero NOT MEASURED this round. Three gates answer exit 3 — PREREQUISITE NOT MET, neither green nor red — until the whole workspace is built:
check:dual-build-cjs-loads,check:i18nandcheck:type-check-debt. Rather than declare them to CI, the closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*'— 72 tasks, all successful) and all three re-run to a real verdict: exit 0, exit 0, exit 0.check:type-check-debtre-measured 5 ledger entries, 55 raw tsc errors, none above its recorded number, surplus: none.check:route-envelopeis in dispatch-gates' "silent / weakest verdict" bucket (#16828) so it never appears in a derived union. Checked explicitly rather than skipped: no file this diff touches containsc.json(orres.json(, so it does not apply here.origin/mainadvances faster than a union can be re-derived and re-run against it, so the last derivation ran one commit behind with one gate-source file changed in that range —scripts/engine-double-contract.pinned.json, which this diff also edits.origin/mainwas merged once more afterwards (no conflict, including in that ledger) and the families that file and the edited test file derive were re-run on the merged tree:check:engine-double-contract,check:test-source-alias,check:type-source-resolution,check:where-matcher,check:objectql-double-limit,check:query-options-erasure,check:type-check-coverage,check:swallow-census-controls,check:nul-bytes,check:doc-authoring,check:i18n-stale-fill,check:cross-package-test-inputs— all exit 0. Closing the last commit of the race is CI's job, on the merge commit it builds.pnpm lint— a proven narrowing, not a skipped run. Run over the six lintable source files this diff touches:--format jsonreports 6 files linted, 0 errors, 0 warnings. Independent control in this tree:parserOptions.projectappears nowhere ineslint.config.mjs, and a grep for the typed@typescript-eslintrule family (no-floating-promises,no-unsafe-*,await-thenable,no-misused-promises,require-await,restrict-*) returns 0. The narrowing is sound because this repo runs oneeslint.config.mjswhich never enables type-aware linting for any file (noparserOptions.project, no typed@typescript-eslintrules — stated and measured with a positive control in that file's own comment at theQUERY_OPTIONS_TEST_GLOBSblock), so nothing in this diff can move the verdict on a file it does not touch. The repo-wide sweep remains CI's.Reviewer's fast path
Three hunks. In
packages/objectql/src/engine.ts,insert()runs its two value-changing strips, then calls one new optional callback, then proceeds to the producers; theinsertDroppedreporting block is unmoved. Inpackages/plugins/plugin-security/src/security-plugin.tsstep 3.6, theinsertbranch installs that callback where it used to match a filter againstopCtx.data; theupdatebranch is the old code, unmoved. Everything else is the conformance file, the doubles learning to model the engine, and gate bookkeeping.验收备注
beforeUpdatestamp rewrites the checked field after the middleware merged its pre-image is ADMITTED, and the row is stored in an organization the caller does not hold. Filed as plugin-security: the UPDATE-side RLScheckpost-image ispre-image + change setevaluated beforebeforeUpdate, so a hook-stamped scoping field can move a row into an organization the caller does not hold #16790. Not fixed here on purpose: the ruling names the insert side and treats the update path as the correct reference, so extending the evaluation point to a second verb is a second accept/refuse change that wants its own decision. The mechanism this PR adds is directly reusable for it. The conformance file states out loud that it does not claim that route, rather than pinning today's answer there — a test asserting the defective behaviour would advertise a guarantee the runtime does not deliver.check-clause2-carriers --pair 16805, re-read for the review's F7 — the exit-4 note this body used to carry is RETIRED, and the reason it described is FIXED. That note said the pair was clause-② illegible because the claim comment opened## Claim: …whileCLAIM_COMMENT_MARKERonly accepts a claim at column 0. The PM posted the compliant spelling on 5580367981 (Claim: … · Clause-②: yes, column 0, explicitly a format correction and not a second claim), so the carrier that note sent a reader to fix no longer needs fixing.--pair 16805exits 3 — PREREQUISITE NOT MET,GET /repos/objectstack-ai/objectstack/pulls -> HTTP 403,0 pair(s) had been read. The family's own classifier (check-half-states.mjs --probe, which the gate names as the one thing that classifies its transport) agrees and names the fix: "run this from a container whose egress allows repo-scoped reads (CI, or the Routine seat class); in a proxy-mediated seat, repo-scoped reads stay on themcp__github__*tools". ⛔ Not reported as a pass. What IS readable from here, over MCP: neither carrier holdsneeds:contract-review— both were stripped when the review concluded — and re-hanging them against the patched head is the PM seat's step per the handoff.multi: true) updates are still not post-image validated. Pre-existing, documented in the gate, unmoved here, and explicitly out of plugin-security: array inserts are nevercheck-gated — the write gate's own guard skips any array payload, and the REST import runner reachesengine.insertwith one #16877's scope — an update selected by a predicate cannot form a post-image in the middleware at all, so it is not made cheaper by this seam.ObjectQL.insertmust run the installed judgement or the middleware refuses. Three call sites in two existing test files were updated.check:engine-double-contractcoversdelete/update/findOnebut not this seam, so a future double will learn about it from a red test rather than from that gate.Authored by Claude Code in session
session_012zTkyNHJ7TkuN2oXtP5x37.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37